Skip to content

[OMNIML-6085] Run expert-parallel PTQ from hf_ptq.py - #2754

Open
shengliangxu wants to merge 57 commits into
shengliangx/hfep-base-14-hf-ptqfrom
shengliangx/hfep-14-hf-ptq
Open

shengliangxu wants to merge 57 commits into
shengliangx/hfep-base-14-hf-ptqfrom
shengliangx/hfep-14-hf-ptq

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Note

The expert-parallel PTQ series is now a tree instead of one stack (re-cut 2026-10-27, from main 1521053), so PRs that do not depend on each other can be reviewed and merged in parallel. This PR was [21/21]. Rebased onto main 7c3c868 later the same day, after #2739 merged, and onto main f03651e that night.

Base: shengliangx/hfep-base-14-hf-ptq, a branch (not a PR) that merges #2746 and #2753, the PRs this one builds on. The diff below is this PR's alone, and it merges after all of them.

Re-cut: this PR's diff is unchanged; only its base moved. Review fixes since are listed at the end of Testing.

Series layout

What does this PR do?

Type of change: New feature

hf_ptq.py had one distributed path, --use_fsdp2, which shards a dense model and replicates every expert of an MoE. This PR wires the expert-parallel path (the rest of the series; see the layout above) into it, so the documented CLI reaches an expert-parallel run: recipes, export formats, deprecations.

  • Process group. main() runs inside distributed_run ([OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736), which owns the process group on a distributed launch. The run takes this process's DistributedState (rank, size, device) from it, with --device for a single process. FSDP2 on one rank still shards through a process group, of one, which the load sets up ([OMNIML-6054] Route load_pretrained by the distribution plan #2779) and distributed_run destroys with the run. The example's own DistributedState, setup_distributed_args and process-group helpers are gone.
  • One decision, read everywhere. load_model decides the plan once, with resolve_distribution_plan ([OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737, [OMNIML-6060] Load an expert-parallel plan through load_pretrained #2780, [OMNIML-6076] Describe expert parallelism in the model specs #2746), and loads every model by it through load_pretrained ([OMNIML-6054] Route load_pretrained by the distribution plan #2779): expert-parallel, FSDP2, get_model's whole-model loads and the low-memory load. load_pretrained records the plan, so the script records none itself: [OMNIML-6071] Calibrate a distributed model on per-rank data #2740's record after the --use_fsdp2 load is gone. Everything after reads the plan back from the model, not the flag or the process group: the calibration device, the data sharding, the calibration loop, and get_model's GPU placement.
    • MODELOPT_EP_DEGREE and MODELOPT_FSDP_SIZE override the automatic choices.
    • --use_fsdp2 asks the decision for FSDP2 and no expert parallelism.
  • Refusals. A plan that shards the whole model with FSDP2 raises without --use_fsdp2, the flag this script's FSDP2 checks hang on, which now run before anything is set up. resolve_distribution_plan refuses a plan that would quietly replicate an expert-parallel-capable MoE, and a model whose spec refuses expert parallelism (GPT-OSS, Llama-4, [OMNIML-6076] Describe expert parallelism in the model specs #2746) raises unless --use_fsdp2 rules expert parallelism out. Left to decide, the plan replicates a dense part it cannot FSDP2-shard within several expert-parallel groups, which the export cannot write yet; asking for that layout raises before loading and names the settings that avoid it. An AutoQuantize recipe raises on the expert-parallel and replicated routes, since its search scores each rank's shard without reducing the scores; --use_fsdp2 keeps main's warning. --low_memory_mode, --use_seq_device_map and --offload_folder (with it --max_cpu_memory_gb / --max_gpu_memory_gb) configure a single process's load, so an expert-parallel or replicated run refuses them before loading: an expert-parallel run never reaches those loaders, and a replicated one pins each rank's copy to its GPU, which they cannot work with. A single process and --use_fsdp2 keep main's handling. An expert-parallel run also refuses --vllm_fakequant_export before loading, since that exporter writes the whole model from each process it runs in; a replicated run keeps it (rank 0 exports), and --use_fsdp2 keeps its parsing-time refusal.
  • Export. A model the plan replicates, such as a small dense model under torchrun, is exported by rank 0 alone; every rank used to write the same directory and race on config.json. A multimodal model's source config is rank 0's in every plan. A speculative-decoding model's export, which writes the whole checkpoint from one process, is refused on every rank when the model is sharded.
  • Calibration.
    • Every distributed run calibrates on its own 1/world shard of the data ([OMNIML-6071] Calibrate a distributed model on per-rank data #2740), the offline speculative-decoding loader included; a loader of a kind that cannot be split is refused.

    • Expert-parallel runs go through distributed_forward_loop, which re-plants the all-to-all forwards that quantizer insertion clobbers on transformers 5.16 and later. From transformers 5.16, quantizer insertion drops the routed experts' all-to-all forward wrapper, and a recipe whose calibration runs no loop (configs that need no calibration, such as int8_weight_only or w4a16_nvfp4, and the MX presets, whose algorithm is None) would leave it dropped, so the preview after PTQ would fail before the export. Once quantized, an expert-parallel run therefore re-plants it. On 5.15 and earlier the TP transforms are forward hooks that insertion leaves in place, and the re-plant finds nothing to restore.

    • An expert-parallel VLM calibrated on text runs its batches through the language model, but the loop re-plants the all-to-all forwards over the whole model: transformers keeps the expert-parallel plan on the model from_pretrained built, and from 5.16 a VLM's language model reports none of its own, so a loop handed the language model would re-plant nothing, and the first batch would raise for the missing EP process group. The --qformat path of an expert-parallel VLM also needs [OMNIML-6071] Calibrate a distributed model on per-rank data #2740's guard fix, which lets a module with no routed experts (the vision tower the script quantizes with every quantizer off) pass the expert-parallel calibration check.

    • FSDP2 keeps main's plain loop, with its split-on-OOM retry.

    • The calibration algorithms under a plan that spans several ranks:

      Algorithms
      Supported and run on GPUs max (amax all-reduced over the data-, expert- and tensor-parallel groups), mse (searches the weights max leaves identical on every rank)
      Supported, not yet run on GPUs gptq (Hessians merged across each module's data-parallel replicas), smoothquant (scales from max's synced amax), nvfp4_act_headroom (activation scales MAX-reduced over data parallelism)
      Refused before quantizing ([OMNIML-6071] Calibrate a distributed model on per-rank data #2740) awq_lite, awq_clip, awq_full, svdquant, local_hessian, imatrix, and max or mse with distributed_sync off, also as another algorithm's weight or scale algorithm: each keeps its statistics on its rank, so the ranks' copies of a weight would come out different
      Not assessed lsq's learnable scales (its first scale calibration follows the rows above)
  • Previews keep a sharded model's ranks together. Each rank previews the first sample of its own calibration shard, so the ranks finish decoding at different steps. Under expert parallelism or FSDP2 every forward runs a collective, and a rank that stopped decoding left the others blocked in it: Qwen3-30B-A3B at NVFP4 and EP4 hung in the post-PTQ preview. The previews, Nemotron-VL's included, now generate with synced_gpus when the model's plan shards it. Under expert parallelism a preview that fails raises, which aborts the run, rather than leaving the other ranks in the next step's collective; its error says the export was not reached and names --skip_generate. --use_fsdp2 keeps main's best-effort skip and still exports, which is safe for a failure every rank meets at the same step. A failure on one rank alone leaves the others in generate's collectives until the collective timeout. While a preview decodes, generate is kept from switching a MoE's experts to batched_mm, which bypasses the quantizers, on every module of the model that has the decode hook: a model can generate through a submodule, as a Nemotron-VL model's chat generates through its language model. The guard puts back the hook each module held itself, so a hook bound per instance survives and previews nest. The ranks also decide together whether to run Nemotron-VL's image preview: each node reads the sample images from its own checkout, and the repository ships none.
  • GPU placement. When the plan gives every one of several ranks the whole model, each rank pins it to its own GPU, on every load path (the pack-quantized and MXFP4 loads, DiffusionGemma's sequential map), and --use_seq_device_map, which spreads one model over every GPU, and --offload_folder are refused before loading there. Under a plan with several ranks, a device that names no GPU index is refused before loading, rather than left to accelerate's "auto". A pinned copy is not sized against the node's GPUs, a limit its one-device map would ignore. A single process keeps spreading a model too big for one GPU, as before.
  • Docs. README section "Expert-Parallel Post-Training Quantization for MoE Models": how to launch, what the knobs override, which models are refused, and that GPT-OSS and Llama-4 (refused by expert parallelism, and by --use_fsdp2 for their released MXFP4 / multimodal checkpoints) quantize in a single process.
  • Memory report fix. launch_memory_monitor probed NVML before initializing it. Every probe raised Uninitialized, so the per-GPU peak-memory report never started on any host where nothing else had initialized NVML. The probe now initializes NVML itself, and only rank 0 starts the monitor, which reads every GPU on its node.

Usage

# One node, 8 GPUs: the experts shard over all 8 ranks, each calibrating its own data.
torchrun --nproc_per_node 8 examples/hf_ptq/hf_ptq.py \
    --pyt_ckpt_path Qwen/Qwen3-30B-A3B --qformat nvfp4 --export_path /out/qwen3-30b-a3b-nvfp4

Testing

  • New tests/examples/hf_ptq/test_hf_ptq_expert_parallel.py (70 cases; the decision itself is tested in [OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737, [OMNIML-6060] Load an expert-parallel plan through load_pretrained #2780 and [OMNIML-6076] Describe expert parallelism in the model specs #2746):
    • what the script adds to the decision: FSDP2 without expert parallelism for --use_fsdp2, and FSDP2 sharding refused unless --use_fsdp2;
    • load_model loading by the plan: an EP plan and --use_fsdp2 through load_pretrained and no other loader, a data-parallel one through get_model;
    • per-rank GPU pinning by the plan, and single-process placement left alone;
    • the single-process load options (--low_memory_mode, --use_seq_device_map, --offload_folder) refused on the expert-parallel and replicated routes and kept by a single process;
    • main() running inside distributed_run, which it leaves even when the run raises, and handing the run the state distributed_run yields, with --device for a single process;
    • per-rank calibration data;
    • the distributed loop, with the EP mesh, for an expert-parallel plan, and the plain one for FSDP2.
    • the previews, before and after quantization and Nemotron-VL's, generating with synced_gpus exactly when the plan shards the model (EP or FSDP2 over several ranks);
    • the image preview skipped on every rank when one has no sample image;
    • exporting a replicated model from rank 0 only, and every rank of a sharded one;
    • a sharded speculative-decoding model refused at export on every rank, with nothing written;
    • the plan the export cannot write refused before loading, while the world-size degree still resolves;
    • the vLLM fake-quant export refused on the expert-parallel route and kept by replicated and single-process runs;
    • the all-to-all forwards re-planted after a quantization that runs no calibration loop, for an expert-parallel plan only. A 2-GPU A/B on an earlier revision of this branch (tiny Qwen3-MoE, EP 2 with the dense part replicated, MXFP8 on weight and input quantizers with algorithm None, previews on): on transformers 5.18 the run without the re-plant failed in the post-quantization preview on both ranks ("torch_a2a experts forward requires the EP process_group") and wrote no checkpoint, and with it the run completed, restoring 8 forward wrappers, and exported; on transformers 5.9.0, where there is nothing to re-plant (up to 5.15 the TP transforms are hooks that quantizer insertion leaves alone), both runs exported byte-identical files;
    • a tiny Qwen3-VL-MoE, as the expert-parallel load leaves it, calibrated on text over a one-rank gloo group (fails without the fix on transformers 5.18; skipped below 5.16). On GPUs at this revision, a tiny Kimi-K3, a VLM MoE (bf16, 32 experts), on 2 GPUs at EP 2 with transformers 5.18, NVFP4 with an FP8 KV cache, calibrated on text and previewed before and after PTQ on both ranks: the calibration loop re-installed its 3 experts' forward wrappers, and the run exported;
    • Mutation-checked: each test fails under a deliberate break of the code it covers.
  • New 2-GPU smoke test test_ptq_qwen3_moe_expert_parallel (tests/examples/hf_ptq/test_llm_ptq.py): torchrun --nproc_per_node 2 hf_ptq.py with MODELOPT_EP_DEGREE=2 on a tiny Qwen3-MoE and the FP8 recipe. It checks that every expert of both ranks is exported FP8 with its weight scale, and that the config names FP8. It is the tests' one real expert-parallel run of the script; the file above covers the wiring with stand-ins. It passed on 2 GB200s in 29.5 s at f4bb395488 (2026-10-16), which carries this test on a revision before the latest loader and calibration fixes ([OMNIML-6064] Load each rank's checkpoint shard straight into a meta-built model #2739, [OMNIML-6071] Calibrate a distributed model on per-rank data #2740). It skips on CI's 1-GPU PR runner and runs on 2-GPU runners.
  • tests/examples/hf_ptq/test_hf_ptq_model_type.py (main's): the preview tests' stand-in models are modules now, since the previews read their distribution plan.
  • tests/examples/hf_ptq/test_example_utils.py: the preview keeping generate from switching the experts implementation, also on a language model that a model around it generates through (fails without the fix), a model without the decode hook previewed as it is, and a decode hook the model holds itself put back, also when previews nest.
  • New tests/unit/torch/utils/test_memory_monitor.py, against a fake reference-counted NVML: the monitor starts when NVML can report memory (this fails on the old code), and is skipped with NVML shut down when it cannot. tests/examples/hf_ptq/test_hf_ptq_args.py: the memory monitor started on rank 0 only.
  • Plan tests the library's and get_model's tests cover are dropped, and the load-call, calibration-loop and memory-monitor pairs are one parametrized test each. Each drop is checked by mutation.
  • test_hf_ptq_expert_parallel.py also covers: the pin on the pack-quantized, MXFP4 and DiffusionGemma load paths and the sequential-map refusal; a pinned copy not held to a node-wide memory limit; the offline speculative-decoding loader split across ranks; a failed preview raising on an expert-parallel model and skipped, with the export still run, on a replicated or --use_fsdp2 one; a device that names no GPU index refused where each rank holds its own copy; and an AutoQuantize recipe refused on the routes this script adds.
  • The DeepSeek-V3 multi-rank cases of test_hf_ptq_expert_parallel.py skip below transformers 5.7, where [OMNIML-6076] Describe expert parallelism in the model specs #2746 refuses expert parallelism before the route they exercise; their single-process and --use_fsdp2 cases still run there.
  • Locally on this revision, the stack rebased on main c6fa613: pre-commit over the whole PR against main, at every PR of the stack; and at the stack's tip the full tests/unit on transformers 5.18 and 5.5 and on torch 2.8, and the hf_ptq example tests, each with the same failures as main in that environment. CI runs the full unit suite.
  • The GPU results below were measured on earlier revisions of this branch, before this script's loads moved onto load_pretrained ([OMNIML-6054] Route load_pretrained by the distribution plan #2779, [OMNIML-6060] Load an expert-parallel plan through load_pretrained #2780); this revision's GPU tests run in CI only. The series' GPU tests on this branch: 19 passed on a 4-GPU node. On 1 GPU, CI's PR runner, 6 passed and 13 skipped. They cover:
    • the expert-parallel load with the dense part replicated and sharded, at EP 4 and EP 2;
    • amax sync;
    • the distributed export.
  • Against the series' base, on one GPU node:
    • nox -s gpu (all of tests/gpu, 1 GPU, CI's PR-runner shape): every test failing on this branch also fails at the base, and the only new tests are the series' own.
    • The hf_ptq example lane (tests/examples/hf_ptq, TensorRT-LLM image, 1 GPU): no outcome changes on the 296 shared tests. The series' 30 pass, except 2 that need transformers 5.7.
    • tests/gpu_megatron (2 GPUs): no outcome changes over the 311 tests both runs reached. The rest time out identically at the base in that image.
  • Expert-parallel runs end to end. All of them predate [OMNIML-6073] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074's expert weight-scale fix: on every rank but rank 0, an expert's weight scale came out too large wherever rank 0's expert at the same index had the larger one, which costs precision but never clips; the fix can only tighten such scales.
    • Qwen3-30B-A3B at EP16 over 4 nodes, NVFP4 and FP8: the exports with the dense part FSDP-sharded over the world and with it replicated have byte-identical weights and weight scales; only calibration-derived input scales differ. Every dense FP8 weight is FP8 with its scale, and no NVFP4 weight has the all-zero high nibbles of a lost pack shift.

    • Qwen3-VL-30B and MiniMax-M3: tensor counts match single-GPU exports, and every expert weight is packed.

    • MiniMax-M2.7 (block-FP8 source) at EP16 over 4 nodes, NVFP4: every exported weight name is the source checkpoint's, and routed experts are block_sparse_moe.experts.N.w1/w2/w3. Served by vLLM (0.30 dev build, TP4), it scores 0.94 / 0.93 on gsm8k (5-shot, 100 samples, flexible / strict), against the FP8 source's 0.93 / 0.95.

    • DeepSeek-V3 and GLM: on an earlier revision.

    • Kimi-K3 NVFP4 at EP64 over 16 GB200 nodes, in 10 minutes (peak 155 GiB per GPU on an earlier build of this series), checked against the source checkpoint:

      • the router correction biases match;
      • routed and shared experts carry the names vLLM 0.28's loader reads;
      • dense weights dequantize back to the BF16 source at cosine 0.9955;
      • the remote code, tokenizer and processor files ship with the export;
      • the modules vLLM builds unquantized stay BF16, as in nvidia/Kimi-K3-NVFP4.

      The same checks flag the export made before this series' fixes. vLLM 0.28 loads the export and starts serving, but it cannot serve a Kimi-K3 NVFP4 export with an FP8 KV cache: its FP8-KV path needs an FP8 prefill query, which its NVFP4 linears reject. TBD: generation and gsm8k from the checkpoint-mirror recipe models/moonshotai/Kimi-K3/ptq/nvfp4_experts-fp8_pb_attention on this series' code with the [OMNIML-6073] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074 fix.

  • Re-cut on 2026-10-27 (see the note at the top): pre-commit, mypy included, passes at this head over the whole change against main, and the series' PRs merged together give a tree byte-identical to the former stack's tip. This PR's own unit tests pass at this head on CPU (torch 2.14, transformers 5.18); CI runs the full unit suite.
  • Review fixes, 2026-10-27: the Whisper calibration loader stays whole on every rank, so the preview matches its printed transcript. Each behaviour fix has a test that fails without it; pre-commit, mypy included, and this PR's own unit tests pass at this head.
  • Rebased on 2026-10-27 onto main 7c3c868, after [OMNIML-6064] Load each rank's checkpoint shard straight into a meta-built model #2739 merged: pre-commit, mypy included, and this PR's own unit tests pass at this head, and the series' PRs merged together add no unit-test failure over main (tests/unit/torch utils, export, quantization and models, on transformers 5.18 and 5.5.4 and on torch 2.8). hf_ptq keeps main's removal of the FSDP2 KV-cache AutoQuantize refusal ([OMNIML-5987] [2/3] Support DP/FSDP2 KV AutoQuant and checkpoint resume #2664); main's new test that such a run reaches the distributed load now stubs load_pretrained, which this PR loads every distributed plan through, and an import that only the removed refusal test used is gone.
  • Review fixes, 2026-10-27 (Claude's review after the rebase): --use_fsdp2's checks (validate_fsdp2_supported) run on the checkpoint's config before the plan is decided, as they ran before this PR, so a checkpoint they refuse (VILA, multimodal, pack-quantized) gets their message instead of the meta-device build's error. The new test fails without the change.
  • Review suggestions, 2026-10-27 evening: hf_ptq refuses a layerwise-export recipe on a multi-rank launch, before the load; the memory monitor runs on each node's first rank; the unreachable Whisper loader exemption is gone; the off-GPU warning covers a replicated rank's device.
  • Review suggestions, 2026-10-27 night: one helper, layerwise_export_requested, decides both the pre-load refusal and the layerwise export switch; a comment names the load paths without a device map (Nemotron VL), where each rank builds a whole host copy.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ Single-process and --use_fsdp2 runs behave as before, except that under torchrun a --device other than cuda is now refused (by distributed_run, [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736), where main's --use_fsdp2 ignored it and ran each rank on cuda:<local_rank>. A torchrun launch without --use_fsdp2 now runs an MoE expert-parallel.
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: ✅ New feature, 0.49.0.
  • Did you get Claude approval on this PR?: ❌ Not yet.

Additional Information

Part 21 of 21 of expert-parallel PTQ for Hugging Face MoE models; merges after #2749.

Summary by CodeRabbit

  • New Features
    • Added distributed, expert-parallel post-training quantization for Hugging Face MoE models, with calibration data distributed across ranks and checkpoint shards written in parallel.
    • Added options to control expert-parallel degree and dense-layer FSDP2 sharding, with validation for unsupported configurations.
    • Added synchronized preview generation for distributed runs.
  • Bug Fixes
    • Improved GPU memory monitoring when NVML initialization or memory reporting is unavailable.
  • Documentation
    • Added setup guidance, launch examples, requirements, and distribution options for expert-parallel quantization.

@copy-pr-bot

copy-pr-bot Bot commented Oct 12, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review: 3
  • main
  • release/.*
  • feature/.*

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review.

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: NVIDIA/Model-Optimizer/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Enterprise
  • Run ID: a3be3d46-b98f-4ccd-8e07-1df4498d6504
📥 Commits

Reviewing files that changed from the base of the PR and between 12e1719 and 78f56fe.

📒 Files selected for processing: 9
  • examples/hf_ptq/example_utils.py
  • examples/hf_ptq/hf_ptq.py
  • examples/hf_ptq/vlm_utils.py
  • modelopt/torch/utils/distributed.py
  • tests/examples/hf_ptq/test_hf_ptq_args.py
  • tests/examples/hf_ptq/test_hf_ptq_expert_parallel.py
  • tests/examples/hf_ptq/test_hf_ptq_model_type.py
  • tests/examples/hf_ptq/test_hf_ptq_vision_quantization.py
  • tests/unit/torch/utils/test_distributed_calibration.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Walkthrough

The HF PTQ example adds expert-parallel and dense-FSDP distribution support for model loading, calibration, previews, and checkpoint export. The changes also update NVML initialization around the memory probe and add tests and documentation for these behaviors.

Changes: Hugging Face PTQ distribution

Layer / File(s) Summary
Distribution planning and model loading
examples/hf_ptq/hf_ptq.py, examples/hf_ptq/example_utils.py, tests/examples/hf_ptq/test_hf_ptq_expert_parallel.py, tests/examples/hf_ptq/test_hf_ptq_args.py, tests/examples/hf_ptq/test_hf_ptq_vision_quantization.py, examples/hf_ptq/README.md, CHANGELOG.rst
The example resolves distribution plans, checks supported layouts, and passes plans to pretrained-loading paths. The guide describes expert-parallel configuration and launch options. Tests cover plan selection and loading behavior.
Distributed execution and calibration
examples/hf_ptq/hf_ptq.py, examples/hf_ptq/example_utils.py, modelopt/torch/utils/distributed.py, tests/examples/hf_ptq/test_hf_ptq_expert_parallel.py, tests/examples/hf_ptq/test_llm_ptq.py, tests/unit/torch/utils/test_distributed_calibration.py
The run uses a distributed context with a timeout above 30 minutes. Supported calibration data loaders are sharded across ranks, and expert-parallel plans use distributed_forward_loop. The integration test runs two-rank Qwen3-MoE PTQ and checks the exported FP8 expert weights and scales.
Synchronized previews and checkpoint export
examples/hf_ptq/example_utils.py, examples/hf_ptq/hf_ptq.py, examples/hf_ptq/vlm_utils.py, tests/examples/hf_ptq/test_hf_ptq_expert_parallel.py, tests/examples/hf_ptq/test_hf_ptq_model_type.py
Generation kwargs enable synchronized previews for sharded models. Replicated models export on rank 0; dense-FSDP and expert-parallel models export on all ranks. Tests cover preview kwargs and export participation.

NVML memory probe

Layer / File(s) Summary
NVML probe lifecycle
modelopt/torch/utils/memory_monitor.py, tests/unit/torch/utils/test_memory_monitor.py
The memory monitor initializes NVML before probing GPU memory and shuts it down after the probe. Tests cover supported and unsupported memory reporting.

Priority: Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Ranks as torchrun ranks
  participant PTQ as HF PTQ
  participant Loader as load_pretrained
  participant Data as Calibration loader
  participant Forward as distributed_forward_loop
  participant Export as Checkpoint export
  Ranks->>PTQ: Resolve distribution plan
  PTQ->>Loader: Load model with distribution plan
  PTQ->>Data: Shard calibration data across ranks
  Data->>Forward: Provide rank-local calibration batches
  Forward->>Export: Complete expert-parallel calibration
  Ranks->>Export: Export checkpoint shards
Loading
Merge Risk: Minimal · previous review 78f56

No confirmed issue blocks merging. The distributed speculative-decoding calibration path remains unverified.

Pre-merge checks: Passed 5
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns Passed No CRITICAL security anti-pattern was introduced. The changed modelopt and examples Python code adds no unsafe torch.load, numpy.load(..., allow_pickle=True), eval/exec, or # nosec bypas…
Title check Passed The title clearly and concisely describes the primary change: adding expert-parallel PTQ execution to hf_ptq.py.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Full details: Security Anti-Patterns

Explanation

No CRITICAL security anti-pattern was introduced. The changed modelopt and examples Python code adds no unsafe torch.load, numpy.load(..., allow_pickle=True), eval/exec, or # nosec bypass. Transformer loading receives caller-controlled trust_remote_code values, with defaults set to False; the literal True values occur only in newly added test fixtures. The pull request changes no pyproject.toml or requirements file.

✨ Finishing Touches: 💡 2
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@shengliangxu
shengliangxu added this pull request to stack #2755 October 12, 2026 00:57
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-14-hf-ptq branch from b298b8a to 9364c1b Compare October 12, 2026 01:11
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-13-ep-export branch 2 times, most recently from 4e11e09 to be3cc39 Compare October 12, 2026 02:44
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-14-hf-ptq branch from 9364c1b to 9d9b154 Compare October 12, 2026 02:44
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://NVIDIA.github.io/Model-Optimizer/pr-preview/pr-2754/

Built to branch gh-pages at 2026-10-27 00:05 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 62.69%. Comparing base (ca98606) to head (481197b).

Additional details and impacted files
@@                       Coverage Diff                        @@
##           shengliangx/hfep-11c-kimi-k3    #2754      +/-   ##
================================================================
+ Coverage                         62.63%   62.69%   +0.06%     
================================================================
  Files                               668      668              
  Lines                             74485    74490       +5     
================================================================
+ Hits                              46654    46703      +49     
+ Misses                            27831    27787      -44     
Flag Coverage Δ
unit 62.69% <100.00%> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 9d9b154

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-13-ep-export branch from be3cc39 to bdf08a2 Compare October 12, 2026 08:39
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-14-hf-ptq branch from 9d9b154 to 487a8e8 Compare October 12, 2026 08:39
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 487a8e8

@shengliangxu shengliangxu changed the title [19/19] Run expert-parallel PTQ from hf_ptq.py [OMNIML-6085] [19/19] Run expert-parallel PTQ from hf_ptq.py Oct 12, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-14-hf-ptq branch from 487a8e8 to c8d0e4f Compare October 13, 2026 00:53
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-13-ep-export branch 2 times, most recently from 136543b to 405ef82 Compare October 13, 2026 01:32
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-14-hf-ptq branch from c8d0e4f to 22ac8fc Compare October 13, 2026 01:32
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 22ac8fc

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-14-hf-ptq branch from 22ac8fc to d2b13df Compare October 13, 2026 17:41
@shengliangxu
shengliangxu removed this pull request from stack #2755 October 13, 2026 17:42
@shengliangxu
shengliangxu changed the base branch from shengliangx/hfep-13-ep-export to shengliangx/hfep-13b-load-routing October 13, 2026 17:42
@shengliangxu
shengliangxu added this pull request to stack #2775 October 13, 2026 17:43
@shengliangxu shengliangxu changed the title [OMNIML-6085] [19/19] Run expert-parallel PTQ from hf_ptq.py [OMNIML-6085] [20/20] Run expert-parallel PTQ from hf_ptq.py Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-14-hf-ptq branch from d2b13df to f9293cd Compare October 13, 2026 19:38
Base automatically changed from shengliangx/hfep-13b-load-routing to shengliangx/hfep-13-ep-export October 13, 2026 19:38
@shengliangxu
shengliangxu removed this pull request from stack #2775 October 13, 2026 19:38
@shengliangxu
shengliangxu added this pull request to stack #2781 October 13, 2026 19:40
@shengliangxu shengliangxu changed the title [OMNIML-6085] [20/20] Run expert-parallel PTQ from hf_ptq.py [OMNIML-6085] [21/21] Run expert-parallel PTQ from hf_ptq.py Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-14-hf-ptq branch from f9293cd to 3be65ef Compare October 13, 2026 21:12
…FSDP2

The two-GPU hf_ptq test skipped generation, so no test reached a sharded
preview. A real Qwen3-30B-A3B run did: under FSDP2 Transformers refused
the experts switch generate makes for an FSDP2-wrapped class, after the
previews of an earlier round had hung ranks that finished decoding at
different steps. The test now runs both plans, expert parallelism and
FSDP2, with the previews on, on a tiny Qwen3-MoE.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
hf_ptq refused the TensorRT-LLM exporter on a multi-rank plan but not AutoQuantize, whose search
does not run through distributed_forward_loop and scores each rank's own calibration shard without
reducing the scores across ranks. Ranks could choose different formats per layer, which under
expert parallelism writes a checkpoint of mixed formats. A multi-rank run now refuses the recipe
before the load; --use_fsdp2 keeps main's warning for it, and a single process runs it as before.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…run whose preview fails

Under torchrun each rank pins the whole model to its own GPU, and every load path now honours the
pin: the pack-quantized and MXFP4 paths placed by maps of their own, and DiffusionGemma asked for a
sequential map, so every rank's copy landed on the same GPUs. An explicit --use_seq_device_map,
which spreads one model over every GPU, is refused where each rank holds its own copy.

A preview that fails on a sharded model raises instead of being skipped: its ranks decode in
lockstep, so a rank that walked on to the export left the others blocked in the next step's
collective; raising aborts the run. The Nemotron-VL text and image previews do the same. The
offline speculative-decoding loader is split across ranks like the others, and a loader of a kind
that cannot be split is refused instead of fed whole to every rank.

The load tests read the spec's plan off the decision's copy of the config, which now carries it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Each node reads the VL preview's sample images from its own checkout, and the repository ships none,
so the ranks could disagree on whether there is one. A rank without an image returned before
generating while its peers decoded in lockstep, which left them blocked in the next step's collective
until the process-group timeout. The ranks of a sharded model now decide together: every rank
previews, or none does. Raising instead would stop every sharded Nemotron-VL run on a checkout
without images.

A failed preview that stops the run now says the export was not reached and that --skip_generate
exports without the preview, with the preview's error as its cause.

The GPU memory monitor reads every GPU on the node, so rank 0 starts it rather than every rank.

_resolve_distribution no longer refuses FSDP2 within several expert-data-parallel replicas itself:
no plan from resolve_distribution_plan reaches that check, since resolve_distribution_plan refuses
the layout first, and load_and_prepare_ep keeps its own refusal for a plan handed to it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
An expert-parallel run never reaches get_model or the low-memory loader, so it accepted
--low_memory_mode, --use_seq_device_map and --offload_folder and ignored them: torchrun with
--low_memory_mode loaded an MoE at full precision without a word. A replicated run could not use
them either. Its pin of each rank's copy to the rank's GPU leaves --offload_folder nothing to
offload, and the low-memory loader cannot run under a process group: it all-reduces amax computed
from meta weights, and hands accelerate its own device map next to the pin. Both routes now refuse
the three options before loading, as they refuse the TensorRT-LLM exporter and AutoQuantize. A
single process and --use_fsdp2 keep main's handling.

get_model no longer sizes a pinned copy's memory over the node's GPUs: the pinned map ignores
max_memory, so the limit it printed was never applied.

The README no longer points GPT-OSS and Llama-4 at --use_fsdp2, which refuses their released
checkpoints (an MXFP4 quantization_config, a multimodal config); it says to quantize them in a
single process.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
An expert-parallel run that quantized without running the calibration loop lost its experts'
all-to-all forwards: quantizer insertion drops them, and only distributed_forward_loop re-planted
them, before its first forward. A config without static activation scales (int8_weight_only,
w4a16_nvfp4, ...) calibrates without the loop, and algorithm None (the mxfp4, mxfp6, mxfp8 and
mxint8 presets) never calls the loop it is handed, so the preview after PTQ raised "torch_a2a
experts forward requires the EP process_group" and stopped the run before the export. mono_quantize
now re-plants them once the model is quantized; after a loop that ran, that is a no-op.

An expert-parallel run refuses --vllm_fakequant_export before the load. That exporter writes the
whole model with save_pretrained from each process it runs in, and every rank of a sharded model
exports, so each rank would write its own share of the experts into the same directory. Rank 0
alone still writes a replicated model.

keep_the_experts_implementation puts back an _optimize_model_for_decode the model held itself
instead of deleting it, so a hook bound per instance survives the preview and the manager nests.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The hf_ptq README said a model needing a newer transformers says so before loading, and described
auto's EP degree without the floor; since [13/21], auto resolves to 1 there and the run is refused by
the plan instead. _reinstall_a2a_forwards' docstring named the calibration loop as its only caller;
hf_ptq now calls it after quantizing too.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The preview after quantization raised on a failure whenever the plan sharded the model, which
aborts the run before the export, for --use_fsdp2 as well as for expert parallelism. On main a
failed --use_fsdp2 preview warned and the export ran, and a failure every rank meets at the same
step (an unsupported kernel, the case that skip was written for) still exports that way. Only an
expert-parallel preview now stops the run; --use_fsdp2 keeps main's skip.

get_model pinned a rank's copy to its GPU only for a device with an index, and under a multi-rank
plan fell back to "auto" for one without, the shared-GPU placement its comment warns about. It now
refuses such a device; hf_ptq hands every rank of a launch an indexed cuda:<local_rank>.

The re-plant after quantizing notes it matters from transformers 5.16: earlier releases install the
TP transforms as forward hooks, which quantizer insertion leaves in place.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
An expert-parallel run that calibrated a VLM on text handed distributed_forward_loop the language
model, and the loop re-plants the all-to-all forwards over the model it is handed. transformers
keeps the expert-parallel plan on the model from_pretrained built: from 5.16, tp_plan returns it
only for a module whose own config carries distributed_config, which from_pretrained sets on the
top config alone. The language model's plan named no experts, so the forwards quantizer insertion
drops stayed dropped, and the first batch raised "torch_a2a experts forward requires the EP
process_group". The loop now takes the whole model, as it already did for image batches, and runs
text batches through the language model; quantization still targets the language model alone.

A CPU test builds a tiny Qwen3-VL-MoE as the expert-parallel load leaves it and calibrates it on
text over a one-rank gloo group; without the change it fails on transformers 5.18.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
keep_the_experts_implementation planted its stand-in for generate's _optimize_model_for_decode on
the model it was handed only. A Nemotron-VL model's chat generates through its language model,
whose generate then entered its own hook and switched the experts to batched_mm: under expert
parallelism that forward is not the torch_a2a one and raises, which stops the run before the
export; otherwise the preview after PTQ decoded on unquantized experts. The guard now covers every
module of the model that has the hook, and puts back what each one held.

The post-PTQ preview's comment also says what a failure on one --use_fsdp2 rank alone does: the
other ranks stay blocked in generate's collectives until the collective timeout.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…ngelog

hf_ptq now runs inside distributed_run, which refuses a device other than cuda under a launch;
on main a multi-rank torchrun ignored --device. Record that in Backward Breaking Changes.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
export_speculative_decoding writes the whole checkpoint from one process and knows no plan, so under
an expert-parallel or FSDP2 plan every rank reached it and wrote the same files over each other.
export_quantized now refuses that export for a sharded model, on every rank alike, before writing
anything; a replicated model still exports it from rank 0.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The expert-parallel load now requires 5.7, the first release that builds a model with torch_a2a.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
pre_quantize called full_model.generate for every model it did not skip by type, and a remote-code
class that does not inherit GenerationMixin has no generate: Kimi-K3's KimiK3ForConditionalGeneration
stopped the run there with an AttributeError, before quantization, unless --skip_generate was passed.
Such a model now skips the preview, before quantization and so after it too.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The skip was for Kimi-K3, whose model had no generate, but only rank 0's lacked it: the plan
decision's meta build had registered the remote class with the auto class, so rank 0's load built
it as registered, without the GenerationMixin every other rank's first load adds. [5/21] now
undoes that registration, and every rank's Kimi-K3 has generate. The skip, decided on each rank
alone, instead let rank 0 move on to calibration while the others decoded in lockstep, a deadlock
where the missing method would have raised.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
[13/21] holds every expert-parallel spec to the expert-parallel load's transformers floor, so on
transformers 5.5, the CI floor, a multi-rank DeepSeek-V3 run is refused before the hf_ptq route
these cases exercise. They skip below the floor; their single-process and --use_fsdp2 cases still
run there.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The preview prints the transcript of the set's first sample next to each rank's first output, so a
sharded loader paired every rank but rank 0 with another sample's transcript. With the amax synced
across the ranks, the calibration comes out the same as from shards.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
It sat third, so a caller passing gpu_mem_percentage or a later option by position handed it to
distribution instead. Every caller in the tree passes it by keyword, as they keep doing.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…he guard

It does not: the plain loop runs under the same guard, so the distributed loop restores nothing
there.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…ent tests

Main replaced the FSDP2 KV-AutoQuantize refusal test this PR had pointed at transformers.AutoConfig,
so nothing in the file uses the import any more.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Main's new test checks that an FSDP2 KV-AutoQuantize run reaches the distributed load by stubbing
parallel_load_and_prepare_fsdp2, which hf_ptq no longer calls: it loads every distributed plan through
load_pretrained. The test stubs that instead, as its MTP neighbour does.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
On a launch of several ranks, deciding the plan builds the model on the
meta device, also when --use_fsdp2 pins both knobs. Some checkpoints
validate_fsdp2_supported refuses (VILA, multimodal, pack-quantized)
cannot be built there, and that build's error names neither
--use_fsdp2 nor the way out. The checks read the config alone, so they
now run first, as they did before the run decided its plan.

The DeepSeek-V3 test config names its architecture, as a checkpoint's
does, since the checks read it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The check compared the device with the string "cuda", so a replicated
run, whose ranks pass torch.device("cuda:<local_rank>"), never printed
the warning, though a pinned copy that accelerate spilled to the CPU is
where it matters most. It now compares the device type.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A run of several ranks never calibrates Whisper: _resolve_distribution
refuses every encoder-decoder model the TensorRT-LLM exporter takes,
Whisper included, before the load. The exemption and its test covered
a configuration no launch reaches.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
It reads every GPU on its node, so one per rank polls them all once per
rank, but gating it on global rank 0 left every node but the first
unreported on a multi-node run, where expert-parallel ranks hold
different experts. Each node's local rank 0 starts it now.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Per-layer export writes the same shard files from every rank.
assert_layerwise_export_supported refuses that, but only once
calibration binds the exporter, after the expert-parallel load placed
the model on every rank. _resolve_distribution refuses it before the
load, as it does the other single-process-only options.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The Nemotron VL load replaces the device map with None and moves the
model to the rank's GPU only at the end, through host memory, so not
every path below honours the pin as the comment said.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The note read as the opposite of what it meant. The loads with no
device map (Nemotron VL) skip the pin: each rank builds a whole copy in
host memory before moving it to its GPU, so a node needs one host copy
per rank.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The refusal before a distributed load and quantize_main's export switch
each spelled out the test for a layerwise block that exports, and only
the two staying identical kept the refusal covering the runs it is for.
Both now ask layerwise_export_requested.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test aa15aa6

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants